[iOS] AVFoundation(AVCaptureVideoDataOutput/AVCaptureAudioDataOutput)でVine風の継ぎ足し撮影アプリを作ってみた
1 はじめに
Vine (ヴァイン)とは Twitter に最大6秒の短い動画を撮影・投稿できる Twitter 公式アプリです。 (1月17日でサービス終了のアナウンスがあります)
このアプリでは、動画を細切れに撮影し、最終的に連結して1つの動画を作成できます。細切れで撮影できるがゆえ、非常にユニークな動画が多数公開され、人気となっていました。
本記事は、AVFoundationを使用して、Vineのように「細切れで撮影した映像を1つの動画ファイルとして保存」する方法を紹介するものです。
AVFoundationを使用する場合の共通的な処理については、下記に纏めましたので、是非御覧ください。
[iOS] AVFundationを使用して、「ビデオ録画」や「連写カメラ」や「QRコードリーダー」や「バーコードリーダー」を作ってみた
2 入出力
「細切れで撮影した映像を1つの動画ファイルとして保存」する場合、AVCaptudeSessionの入力として、カメラ(今回は背面)とマイクを設定し、出力に、動画データであるAVCaptureVideoDataOutputと音声データであるAVCaptureAudioDataOutputを繋ぎます。 そして、この2つの出力をAVAssetWriterで動画ファイルとして保存します。
下記のコードは、AVCaptureSessionを生成して、上記のとおり入出力をセットしている例です。
// セッションのインスタンス生成 let captureSession = AVCaptureSession() // 入力(背面カメラ) let videoDevice = AVCaptureDevice.defaultDevice(withMediaType: AVMediaTypeVideo) let videoInput = try! AVCaptureDeviceInput.init(device: videoDevice) captureSession.addInput(videoInput) // 入力(マイク) let audioDevice = AVCaptureDevice.defaultDevice(withMediaType: AVMediaTypeAudio) let audioInput = try! AVCaptureDeviceInput.init(device: audioDevice) captureSession.addInput(audioInput); // 出力(映像) let videoDataOutput = AVCaptureVideoDataOutput() captureSession.addOutput(videoDataOutput) // 出力(音声) let audioDataOutput = AVCaptureAudioDataOutput() captureSession.addOutput(audioDataOutput)
3 AVCaptureVideoDataOutput/AVCaptureAudioDataOutputのデータ
AVCaptureVideoDataOutputでは、AVCaptureVideoDataOutputSampleBufferDelegateプロトコルを実装したクラスのcaptureOutput(_:didOutputMetadataObjects:from:)でフレーム毎のデータを受け取ることが出来ます。
また、AVCaptureAudioDataOutputでは、AVCaptureAudioDataOutputSampleBufferDelegateプロトコルを実装したクラスのcaptureOutput(_:didOutputMetadataObjects:from:)で音声のデータを逐次受け取れます。
両方共、データを受け取るメソッドが同じなので、デリゲートを同じクラスすると、両方のデータが一緒に入ってくることになります。
試験的に、両方のデータを同じ所で受け取るようにして、内容を確認してみます。
下記のコードでは、個々のデータのCMSampleTimingInfoを取り出し、PTS(presentationTimeStamp)から前回のデータとの時間差を表示しています。 また、Videoのデータに関しては、フレーム数のカウントも表示しています。
var lastVideo: Int64 = 0 // 一つ前の時間情報を保存する(Video用) var lastAudio: Int64 = 0 // 一つ前の時間情報を保存する(Audio用) var frameCounter = 0 // フレームのカウント // 両方のデータを、このメソッドで受け取ることが出来る func captureOutput(_ captureOutput: AVCaptureOutput!, didOutputSampleBuffer sampleBuffer: CMSampleBuffer!, from connection: AVCaptureConnection!) { // VideoとAudioのどちらのデータかを判断する let isVideo = captureOutput is AVCaptureVideoDataOutput // CMSampleTimingInfoを読み取る var count: CMItemCount = 1 var info = CMSampleTimingInfo() CMSampleBufferGetSampleTimingInfoArray(sampleBuffer, count, &info, &count); if isVideo { // 1つ前との時間差 let offset = CGFloat(info.presentationTimeStamp.value - lastVideo)/CGFloat(info.presentationTimeStamp.timescale) print(String(format: "video offset:%.3fsec frame:%d", offset,frameCounter)) // 今回の時間を保存 lastVideo = info.presentationTimeStamp.value // フレーム数のカウント frameCounter += 1 } else { // 1つ前との時間差 let offset = CGFloat(info.presentationTimeStamp.value - lastAudio)/CGFloat(info.presentationTimeStamp.timescale) print(String(format: "audio offset:%.3fsec", offset)) // 今回の時間を保存 lastAudio = info.presentationTimeStamp.value } }
結果は下記のとおりです。
audio offset:0.023sec // 開始直後はAudioデータのみが入ってくる audio offset:0.023sec audio offset:0.023sec audio offset:0.023sec audio offset:0.023sec audio offset:0.023sec audio offset:0.023sec audio offset:0.023sec audio offset:0.023sec audio offset:0.023sec audio offset:0.023sec audio offset:0.023sec audio offset:0.023sec video offset:626023.019sec frame:0 // lastVideoが初期化されていないため、差分が計算できていない audio offset:0.023sec video offset:0.033sec frame:1 audio offset:0.023sec audio offset:0.023sec video offset:0.033sec frame:2 // audio offset:0.023sec // VideoとAudioのデータは1対1ではない audio offset:0.023sec // video offset:0.033sec frame:3 audio offset:0.023sec video offset:0.033sec frame:4 audio offset:0.023sec audio offset:0.023sec audio offset:0.023sec audio offset:0.023sec audio offset:0.023sec audio offset:0.023sec audio offset:0.023sec audio offset:0.023sec audio offset:0.023sec video offset:0.200sec frame:5 // 正確に1/30毎にフレームが来ているわけでは無いようだ audio offset:0.023sec video offset:0.033sec frame:6 audio offset:0.023sec video offset:0.033sec frame:7 audio offset:0.023sec audio offset:0.023sec video offset:0.033sec frame:8 audio offset:0.023sec video offset:0.033sec frame:9 audio offset:0.023sec video offset:0.033sec frame:10 audio offset:0.023sec audio offset:0.023sec
試験結果から次のような状況が読み取れると思います。
- Videoのデータは、概ね0.033秒毎に来ている(フレームレートを1/30秒に設定しているため)
- Videoのデータは、完全に等間隔とは言い切れない
- Audioデータは、Videoデータより多くのデータが来ている
- VideoデータとAudioデータの比は、一定ではない
- 開始直後は、Audioデータのみが多数来ている(Videoデータの開始は遅れる)
この辺を踏まえて、このデータを扱う必要がありそうです。
4 AVAssetWriter
AVAssetWriterを使用すると、先のcaptureOutput(_:didOutputMetadataObjects:from:)で受け取れるデータであるCMSampleBufferから、各種のオーディオコンテナタイプ(QuickTimeやMPEG4など)のファイルを作成する事ができます。
下記のコードは、AVAssetWriterのインスタンスを生成し、Video用とAudio用の2つのAVAssetWriterInputを追加しているものです。
// AVAssetWriter生成 let writer = try? AVAssetWriter(outputURL: URL(fileURLWithPath: filePath!), fileType: AVFileTypeQuickTimeMovie) // Video入力 let videoOutputSettings: Dictionary<String, AnyObject> = [ AVVideoCodecKey : AVVideoCodecH264 as AnyObject, AVVideoWidthKey : width as AnyObject, AVVideoHeightKey : height as AnyObject ]; let videoInput = AVAssetWriterInput(mediaType: AVMediaTypeVideo, outputSettings: videoOutputSettings) videoInput.expectsMediaDataInRealTime = true writer.add(videoInput) // Audio入力 let audioOutputSettings: Dictionary<String, AnyObject> = [ AVFormatIDKey : kAudioFormatMPEG4AAC as AnyObject, AVNumberOfChannelsKey : channels as AnyObject, AVSampleRateKey : samples as AnyObject, AVEncoderBitRateKey : 128000 as AnyObject ] let audioInput = AVAssetWriterInput(mediaType: AVMediaTypeAudio, outputSettings: audioOutputSettings) audioInput.expectsMediaDataInRealTime = true writer.add(audioInput)
AVAssetWriterは、データ追加を始める前に、startWriting()で、ステータスを変更し、開始時間を設定します。
writer?.startWriting() writer?.startSession(atSourceTime: kCMTimeZero) // 注:ここでは、開始時間を0で初期化しています
そして、該当するAVAssetWriterInputに対して、CMSampleBufferを追加するだけです。
if isVideo { if (videoInput?.isReadyForMoreMediaData)! { videoInput?.append(sampleBuffer!) } }else{ if (audioInput?.isReadyForMoreMediaData)! { audioInput?.append(sampleBuffer!) } }
終了は、finishWriting(completionHandler:)をコールします。
writer.finishWriting(completionHandler: { // writer.outputURLが出来上がった動画ファイルです })
なお、開始時間を設定する、startSession(atSourceTime:)は、必須ですが、終了時間をセットするendSession(atSourceTime:)は省略可能です。
AVAssetWriterの利用に関しては、詳しくは、サンプルの VideoWriter.swiftをご参照ください。
5 継ぎ足し
単純にデータを追加するだけであれば、startSession(atSourceTime:)に最初のデータの時間を設定して、後は、CMSampleBufferをどんどん追加するだけで、特に問題はありません。
しかし、今回のように、継ぎ足しで動画を撮影する場合、データに空白が生じます。 そして、一部のPTSが長く空いたりすると、その動画ファイルは再生が出来なくなってしまいます。
そこで、今回は、一時停止していた時間の分だけ、その後のPTSを修正し、連続した時間になるようにしています。
修正する時間は、オフセット時間として、一時停止するたびに増加させ、その分を引いて保存しています。
var copyBuffer : CMSampleBuffer? // 取得したデータのCMSampleTimingInfoを取り出す var count: CMItemCount = 1 var info = CMSampleTimingInfo() CMSampleBufferGetSampleTimingInfoArray(sampleBuffer, count, &info, &count) // PTSの調整(offSetTimeだけマイナスする) info.presentationTimeStamp = CMTimeSubtract(info.presentationTimeStamp, offsetTime) // PTSを調整したCMSampleTimingInfoで、新たにCMSampleBufferを生成する CMSampleBufferCreateCopyWithNewTiming(kCFAllocatorDefault,sampleBuffer,1,&info,©Buffer)
また、計算しやすいように、開始時間も0にしてしまっています。
offsetTime = CMSampleBufferGetPresentationTimeStamp(sampleBuffer) // 開始時間を0とするために、開始時間をoffSetに保存する writer?.startWriting() writer?.startSession(atSourceTime: kCMTimeZero) // 開始時間を0で初期化する
6 動作確認
サンプルのアプリを実行している様子です。
画面下部の録画ボタンを押している間だけ撮影が進行し、合計で6秒の撮影が完了したら、データを保存するかどうかのアラートが表示されます。
アラートで「はい」を選択すると、動画データはライブラリに保存されます。
7 最後に
今回は、AVCaptureVideoDataOutput及びAVCaptureAudioDataOutputのデータから動画ファイルを生成してみました。 この方法であれば、データにエフェクトを掛けたり、PTSを修正する事などが自由に行なえます。
色々、面白いものが作れそうな予感がします。
コードは下記に置いています。気になるところが有りましたら、ぜひ教えてやってください。
[GitHub] https://github.com/furuya02/AVCaptureVideoDataOutputSample_Concatenation
8 参考資料
API Reference AVAssetWriter
API Reference AVCaptureVideoDataOutput
API Reference AVCaptureAudioDataOutput
[iOS] AVFundationを使用して、「ビデオ録画」や「連写カメラ」や「QRコードリーダー」や「バーコードリーダー」を作ってみた